"""
    @author    Thomas Lehmann
    @file      simple-python-checker.py
    @language  Please use python >= 3.2

    @note Using the tool against itself should never throw any warning or error!
    @note I don't use TAB's and indentation is 4.
    @note I'm using the documentation tool 'epydoc' (http://epydoc.sourceforge.net/)
          for generating HTML documentation of this.

    @todo verifying that given file is a python file
    @todo new limit: relation of code/comments per file
    @todo new limit: relation of code/comments per class
    @todo new limit: relation of code/comments per function/method
    @todo check for missing class documentation (warning)
    @todo check for undocumented parameters in function/method (warning)
    @todo check for wrong documented parameters in function/method (warning)
    @todo allow first parameter for tool to be a folder/path instead of a file.

    @see <a href="http://en.wikipedia.org/wiki/Source_lines_of_code">
          http://en.wikipedia.org/wiki/Source_lines_of_code</a>
    @see <a href="http://www.dwheeler.com/sloccount/sloccount.html">
          http://www.dwheeler.com/sloccount/sloccount.html</a>
"""
__docformat__ = "javadoc en"

import sys, os, time, ast

from Limits      import Limits
from SimpleTable import SimpleTable

CLASS         =  1
FUNCTION      =  2
LINENR        =  3
LEVEL         =  4
PARAMETERS    =  7
CSTMS         =  8
DOCUMENTATION = 10
NEWLINE       = "\n"

FILE          = 20
LINES         = 21
FUNCTIONS     = 22
METHODS       = 24
LOC           = 26
COM           = 28
BLINES        = 30

class Report:
    def __init__(self):
        self.columnIndices = {}
        self.rowIndices    = {}
        self.table         = SimpleTable()

        self.columnIndices[FILE]      = self.table.appendColumn("File")
        self.columnIndices[LINES]     = self.table.appendColumn("Lines")
        self.columnIndices[FUNCTIONS] = self.table.appendColumn("Fkts")
        self.columnIndices[METHODS]   = self.table.appendColumn("Mthds")
        self.columnIndices[LOC]       = self.table.appendColumn("LOC")
        self.columnIndices[COM]       = self.table.appendColumn("COM")
        self.columnIndices[BLINES]    = self.table.appendColumn("BLINES")

    def dump(self):
        """ prints the table to stdout """
        self.table.dump()

    def setValue(self, pathAndFileName, key, value):
        if not key in self.columnIndices:
            return False

        column = self.columnIndices[key]
        row    = 0

        if not pathAndFileName in self.rowIndices:
            row = self.table.appendRow()
            self.rowIndices[pathAndFileName] = row
            self.table.setValueAt(pathAndFileName, row, self.columnIndices[FILE])
        else:
            row = self.rowIndices[pathAndFileName]

        self.table.setValueAt(value, row, column)
        return True

    def getValue(self, pathAndFileName, key):
        if not key in self.columnIndices:
            return None

        if not pathAndFileName in self.rowIndices:
            return None

        column = self.columnIndices[key]
        row    = self.rowIndices[pathAndFileName]
        return self.table.getValueAt(row, column)

    def increment(self, pathAndFileName, key):
        if not key in self.columnIndices:
            return False

        column = self.columnIndices[key]
        row    = 0

        if not pathAndFileName in self.rowIndices:
            row = self.table.appendRow()
            self.rowIndices[pathAndFileName] = row
            self.table.setValueAt(pathAndFileName, row, self.columnIndices[FILE])
        else:
            row = self.rowIndices[pathAndFileName]

        previousValue = self.table.getValueAt(row, column)
        if previousValue == None:
            previousValue = 0
        self.table.setValueAt(previousValue+1, row, column)
        return True

class FileData:
    """ represent all information for one file from one analyse """
    def __init__(self, pathAndFileName):
        """ initializes members for one file analyse """
        self.pathAndFileName = pathAndFileName
        self.classes         = {}
        self.attributes      = {}
        self.functions       = {}

        self.errors            = 0
        self.warnings          = 0
        self.messages          = []

    def findFunctionByLineNr(self, lineNr):
        """ try to find a function at given line number
            @param lineNr   a valid line number.
            @return when found the dictionary for the function other None.
        """
        functions = [ function for function in self.functions.values() \
                      if function[LINENR] < lineNr < function[LINENR]+function["lines"]]

        if len(functions) == 1: return functions[0]
        return None

class SimplePythonChecker(ast.NodeVisitor):
    """ As visitor on one side it can traverse the python code
        for checking structural things. Comments, length of lines,
        indentation depth are topic which need to be handled separately.

        @note The tool does not check whether somebody creates attributes
              outside of the __init__ method.
    """
    def __init__(self):
        """ initializing to have some empty containers and the limits """
        self.limits         = Limits()     # provides access to individual limits
        self.files          = {}           # container for instances of class 'FileData'
        self.constantValues = {}           # container of constant values with lineno's
        self.startTime      = 0            # timestamp when analyse of one file has started
        self.current        = {LEVEL: 0, CLASS: None, FUNCTION: None}
        self.report         = Report()

    def printMessage(self, pathAndFileName, lineNr, message):
        """ provides central way of displaying a message. You can adjust it in a way
            that your editor displays click able warning and error messages so you
            can easily navigate to the concrete lines.

            @param pathAndFileName   path and filename the message relates to
            @param lineNr            lineNr which relates to the message.
            @param message           is a text with information
        """
        print("  File \"%s\", line %d - %s" % (pathAndFileName, lineNr, message))

    def analyze(self, pathAndFileName):
        """ main method for analyzing one python file
            @param pathAndFileName
                    path and filename of a python file to analyze"""
        self.startTime = time.clock()
        print("...analyzing %s..." % pathAndFileName)
        # remember the current file for further processing
        newFileData = FileData(pathAndFileName)
        self.files[pathAndFileName] = newFileData
        self.current[FILE]          = newFileData

        # the content of the whole file
        code = open(pathAndFileName).read()
        # collecting relevant information for limit checking walking the AST tree.
        tree = ast.parse(code)
        self.visit(tree)
        # line based analyse
        self.analyzeCode(code)
        # checking collected information
        self.checkLimits()
        self.finalize()

    def analyzeCode(self, code):
        """ line based analyse.
            @param code   the content of the whole file (usually) """
        currentFile = self.current[FILE]

        # checking all line lengths for given file (code)
        lineNr       = 1
        for line in code.split(NEWLINE):
            # checking for line length
            limitToCheck = self.limits.maxCharactersPerLine()
            value        = len(line)
            self.checkLimit(lineNr, limitToCheck, value)

            # checking for tabs in line
            limitToCheck = self.limits.maxTabs()
            value        = line.count("\t")
            self.checkLimit(lineNr, limitToCheck, value)

            # counting blank lines
            if not len(line.strip()):
                self.report.increment(currentFile.pathAndFileName, BLINES)

                function = currentFile.findFunctionByLineNr(lineNr)
                if function:
                    function["blankLines"] += 1

            lineNr += 1

            # is a single comment line
            if not line.strip().find("#"):
                self.report.increment(currentFile.pathAndFileName, COM)

                function = currentFile.findFunctionByLineNr(lineNr)
                if function:
                    function["commentLines"] += 1

        # checking number of lines in given file (code)
        limitToCheck = self.limits.maxLinesPerFile()
        lineNr       = 1
        value        = code.count(NEWLINE) + 1
        self.report.setValue(currentFile.pathAndFileName, LINES, value)

        value       -= self.report.getValue(currentFile.pathAndFileName, BLINES)
        value       -= self.report.getValue(currentFile.pathAndFileName, COM)

        self.checkLimit(lineNr, limitToCheck, value)
        self.report.setValue(currentFile.pathAndFileName, LOC, value)

    def visit_ClassDef(self, node):
        """ stores the class.
            @param node   walking the AST tree a class definition has been found
        """
        currentFile = self.current[FILE]
        newClass    = {LINENR: node.lineno, "attributes": set(), "functions": set()}

        currentFile.classes[node.name] = newClass
        self.current[CLASS] = node.name
        self.generic_visit(node)
        self.current[CLASS] = None

    def visit_Attribute(self, node):
        """ stores attributes and counts them class wise
            @param node
                walking the AST tree an attribute has been found. In python you
                cannot declare so I decide that the c'tor (__init__) has to initialize
                all valid members and only those count for the limit checking
        """
        currentFile = self.current[FILE]

        if FUNCTION in self.current and self.current[FUNCTION] == "__init__":
            newAttribute = {LINENR: node.lineno}

            # provide class information (when available)
            if CLASS in self.current and self.current[CLASS]:
                newAttribute["class"] = self.current[CLASS]
                currentFile.classes[self.current[CLASS]]["attributes"].add(node.attr)

            currentFile.attributes[node.attr] = newAttribute
        self.generic_visit(node)

    def visit_FunctionDef(self, node):
        """ stores function and register it at class if it's a method.
            @param node
                walking the AST tree a function/method definition has been found.
                It's to check for too many parameters, for too many lines in
                a function/method and for complexity.
        """
        currentFile = self.current[FILE]
        newFunction = {LINENR: node.lineno, CSTMS: 0, "blankLines": 0, "commentLines": 0}

        # this is because of handling different python versions...
        try:    newFunction[PARAMETERS] = [arg.arg for arg in node.args.args]
        except: newFunction[PARAMETERS] = [arg.id for arg in node.args.args]

        topic = "function"
        if "self" in newFunction[PARAMETERS]: topic = "method"

        # provide class information (when available)
        if CLASS in self.current and self.current[CLASS]:
            currentClass = currentFile.classes[self.current[CLASS]]
            if node.lineno <= currentClass[LINENR]:
                self.current[CLASS] = None
            else:
                newFunction["class"] = self.current[CLASS]
                currentClass["functions"].add(node.name)

        currentFile.functions[node.name] = newFunction
        self.current[FUNCTION] = node.name
        self.generic_visit(node)
        # calcualtes number of lines of function/method
        newFunction["lines"] = self.current[LINENR] - node.lineno
        self.current[FUNCTION]  = None

    def visit_If(self, node):
        """ required to check for indentation level and complexity """
        currentFile = self.current[FILE]
        self.current[LEVEL] += 1

        limitToCheck = self.limits.maxIndentationLevel()
        lineNr       = node.lineno
        value        = self.current[LEVEL]

        self.checkLimit(lineNr, limitToCheck, value)

        if self.current[FUNCTION] in currentFile.functions:
            currentFunction = currentFile.functions[self.current[FUNCTION]]
            currentFunction[CSTMS] += 1

        self.generic_visit(node)
        self.current[LEVEL] -= 1

    def visit_For(self, node):
        """ required to check for indentation level and complexity
            @param node
                This node represents an 'if' statement (if, elif and else)
        """
        currentFile = self.current[FILE]
        self.current[LEVEL] += 1

        limitToCheck = self.limits.maxIndentationLevel()
        lineNr       = node.lineno
        value        = self.current[LEVEL]

        self.checkLimit(lineNr, limitToCheck, value)

        if self.current[FUNCTION] in currentFile.functions:
            currentFunction = currentFile.functions[self.current[FUNCTION]]
            currentFunction[CSTMS] += 1

        self.generic_visit(node)
        self.current[LEVEL] -= 1

    def visit_While(self, node):
        """ required to check for indentation level and complexity """
        currentFile = self.current[FILE]

        self.current[LEVEL] += 1

        limitToCheck = self.limits.maxIndentationLevel()
        lineNr       = node.lineno
        value        = self.current[LEVEL]

        self.checkLimit(lineNr, limitToCheck, value)

        if self.current[FUNCTION] in currentFile.functions:
            currentFunction = currentFile.functions[self.current[FUNCTION]]
            currentFunction[CSTMS] += 1

        self.generic_visit(node)
        self.current[LEVEL] -= 1

    def visit_Str(self, node):
        """ can check for documentation of a function and of a class and
            for constant values """
        currentFile = self.current[FILE]

        if self.current[FUNCTION] in currentFile.functions:
            currentFunction = currentFile.functions[self.current[FUNCTION]]

            if sys.version.startswith("3."):
                if node.lineno-1-node.s.count(NEWLINE) == currentFunction[LINENR]:
                    currentFunction[DOCUMENTATION] = node.s
            else:
                if node.lineno-1 == currentFunction[LINENR]:
                    currentFunction[DOCUMENTATION] = node.s

        if self.current[CLASS] in currentFile.classes:
            currentClass = currentFile.classes[self.current[CLASS]]
            if node.lineno-1 == currentClass[LINENR]:
                currentClass[DOCUMENTATION] = node.s

        if not node.s in self.constantValues:
            self.constantValues[node.s] = [node.lineno]
        else:
            self.constantValues[node.s].append(node.lineno)

        self.generic_visit(node)

    def visit_Return(self, node):
        """ required to check for complexity """
        currentFile = self.current[FILE]

        if self.current[FUNCTION] in currentFile.functions:
            currentFunction = currentFile.functions[self.current[FUNCTION]]
            currentFunction[CSTMS] += 1

        self.generic_visit(node)

    def generic_visit(self, node):
        """ main reason for overwriting this method is to be able to
            calculate how many line are in a block (function/method/class/...) """
        try:    self.current[LINENR] = node.lineno
        except: pass

        # traverse further nodes
        ast.NodeVisitor.generic_visit(self, node)

    def checkLimits(self):
        """ checking different limits """
        currentFile = self.current[FILE]

        for className in currentFile.classes:
            # checking for number of attributes/members per class
            limitToCheck = self.limits.maxAttributesPerClass()
            value        = len(currentFile.classes[className]["attributes"])
            lineNr       = currentFile.classes[className][LINENR]
            self.checkLimit(lineNr, limitToCheck, value)

            # checking for number of functions/methods per class
            limitToCheck = self.limits.maxFunctionsPerClass()
            value        = len(currentFile.classes[className]["functions"])
            self.checkLimit(lineNr, limitToCheck, value)

        for functionName in currentFile.functions:
            currentFunction = currentFile.functions[functionName]

            # checking for number of parameters per function/method
            limitToCheck = self.limits.maxParametersPerFunction()
            lineNr       = currentFunction[LINENR]
            value        = len(currentFunction[PARAMETERS])

            if "self" in currentFunction[PARAMETERS]:
                value -= 1

            self.checkLimit(lineNr, limitToCheck, value)

            # checking for number of lines per function/method
            limitToCheck = self.limits.maxLinesPerFunction()
            value        = currentFunction["lines"]
            value       -= currentFunction["blankLines"]
            value       -= currentFunction["commentLines"]
            self.checkLimit(lineNr, limitToCheck, value)
            # checking for number of control statements per function/method
            limitToCheck = self.limits.maxControlStatementsPerFunction()
            value        = currentFunction[CSTMS]
            self.checkLimit(lineNr, limitToCheck, value)

            # warning if function or method is not properly documented
            if not DOCUMENTATION in currentFunction or \
               len(currentFunction[DOCUMENTATION].strip()) == 0:
                lineNr  = currentFunction[LINENR]
                message = "warning: Missing function documentation! (doc string)"
                currentFile.messages.append((currentFile.pathAndFileName, lineNr, message))
                currentFile.warnings += 1

        limitToCheck = self.limits.maxValueRepetition()
        for constantValue, locations in self.constantValues.items():
            if constantValue in ["", " ", ".", 0, 1]: continue
            value = len(locations)
            for lineNr in locations:
                self.checkLimit(lineNr, limitToCheck, value, constantValue)

        # checking number of functions/methods per file
        limitToCheck = self.limits.maxFunctionsPerFile()
        value        = len(currentFile.functions)
        lineNr       = 1
        self.checkLimit(lineNr, limitToCheck, value)
        # checking number of classes per file
        limitToCheck = self.limits.maxClassesPerFile()
        value        = len(currentFile.classes)
        self.checkLimit(lineNr, limitToCheck, value)

    def checkLimit(self, lineNr, limitToCheck, value, message = ""):
        """ checks for a single limit and prints a message
            if given value is not valid """
        currentFile = self.current[FILE]

        if not limitToCheck.isGreenFor(value):
            baseMessage = limitToCheck.getMessage(value)
            if len(message.strip()) > 0: baseMessage += " - \"%s\"" % message
            currentFile.messages.append((currentFile.pathAndFileName, lineNr, baseMessage))
            if limitToCheck.isRedFor(value): currentFile.errors   += 1
            else:                            currentFile.warnings += 1

    def finalize(self):
        """ printing some final information from last analyse """
        currentFile = self.current[FILE]
        # sorting all messages in order of occurrences (line number)
        for message in sorted(currentFile.messages):
            self.printMessage(message[0], message[1], message[2])
        # functions are those which have not first parameter with name 'self'
        functions = [name for name in currentFile.functions \
                     if len(currentFile.functions[name][PARAMETERS]) == 0 or \
                        not currentFile.functions[name][PARAMETERS][0] == 'self']

        self.report.setValue(currentFile.pathAndFileName, FUNCTIONS, len(functions))
        self.report.setValue(currentFile.pathAndFileName, METHODS, \
                             len(currentFile.functions) - len(functions))

        print("...%3d lines processed in %f seconds." \
              % (self.report.getValue(currentFile.pathAndFileName, LINES), time.clock() - self.startTime))

        self.report.dump()

        print("...%3d warning(s) - %d error(s)" \
              % (currentFile.warnings, currentFile.errors))
        print("...took %f seconds" % (time.clock() - self.startTime))

def main():
    """ script can be executed with filename as parameter, otherwise the
        script itself will be checked """
    print("Simple Python Checker v0.2 by Thomas Lehmann")
    print("...running Python %s" % sys.version.replace("\n", " - "))
    checker = SimplePythonChecker()
    if len(sys.argv) == 2:
        pathAndFileName = sys.argv[1].strip()
        if os.path.isfile(pathAndFileName):
            checker.analyze(pathAndFileName)
        else:
            print("...error: file '%s' does not exist" % pathAndFileName)
    else:
        print("...checking tool itself...")
        checker.analyze("simple-python-checker.py")

if __name__ == "__main__":
    main()
